在本系列文前面的篇章,我們已經介紹如何從交易所取得公開資料,並示範如何運用這些數據,從大盤、產業及個股,由上而下進行股市分析。接下來我們要將這些數據進行有系統地整理,打造個人化的股市資料庫。我們會花三天的時間,分成「上」、「中」、「下」部分,一步步建立屬於自己的市場觀察報告。
為了理解之後實作的內容,我們先描繪出 Scraper 應用程式的系統環境圖:

Scraper 應用程式指就是我們透過 Nest CLI 建立的 scraper application。在 Scraper 應用程式中,主要包含以下模組:
我們會在本系列的「上」、「中」、「下」部分,分別完成這些服務。在今日的「上」篇,我們將會完成「Market Stats Module」與「Ticker Module」。
在本系列文的第一天,我們已經進行開發環境的準備,因此 MongoDB 已經安裝就緒並且可隨時建立資料庫連線。
Mongoose 在 Node.js 社群是非常知名的 MongoDB ODM(Object-Document Mapping),可在 MongoDB 和 Node.js 環境之間建立連線,並提供 API 方法便於處理 MongoDB 的各項操作。Nest 也提供官方套件支援 mongoose,請打開終端機,我們安裝以下依賴套件:
$ npm install --save @nestjs/mongoose mongoose
套件安裝完成後,在專案目錄開啟 src/app.module.ts 檔案,將 MongooseModule 匯入至根模組 AppModule:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ScraperModule } from './scraper/scraper.module';
@Module({
imports: [
MongooseModule.forRoot('mongodb://localhost:27017/speculator'),
ScraperModule,
],
})
export class AppModule {}
在 AppModule 中,匯入 MongooseModule.forRoot() 方法的參數,即是 MongoDB 資料庫的連線位址。假設我們在本機使用的資料庫位置是:
mongodb://localhost:27017/speculator
為了進行更靈活的配置,我們繼續安裝 Nest 官方提供的 @nestjs/config 套件:
$ npm install --save @nestjs/config
安裝完成後,回到 src/app.module.ts 檔案,將 ConfigModule 匯入 AppModule,並調整設定如下:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { ScraperModule } from './scraper/scraper.module';
@Module({
imports: [
ConfigModule.forRoot(),
MongooseModule.forRoot(process.env.MONGODB_URI),
ScraperModule,
],
})
export class AppModule {}
透過使用 ConfigModule,我們可以配置環境變數,在程式碼中隱藏敏感資訊,並在不同環境下,給予不同的設定值。
請在專案根目錄下新增 .env 檔案,並配置以下變數設定:
MONGODB_URI=mongodb://localhost:27017/speculator
當應用程式執行時,MongoDB 連線位址就可以透過存取環境變數 process.env.MONGODB_URI 的方式讀出。
在
@nestjs/config套件中,提供了各種不同的配置方法。詳細使用方式可參考 官方文件 的說明。
在本系列文前面的篇章,我們介紹了許多評估大盤狀況的方法,現在我們要整理這些數據,建立自己的大盤觀察指標。
我們建立一個 MarketStatsModule 處理大盤籌碼相關數據,打開終端機,使用 Nest CLI 建立 MarketStatsModule:
$ nest g module market-stats
執行後,Nest CLI 會在專案下建立 src/market-stats 目錄,在該目錄下新增 market-stats.module.ts 檔案,並且將 MarketStatsModule 加入至 AppModule 的 imports 設定。
然後在 src/market-stats 目錄下新增 market-stats.schema.ts 檔案,我們要定義 MarketStatsSchema,這代表的是一個 Mongoose Schema,定義每個交易日要收集的大盤籌碼數據:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type MarketStatsDocument = MarketStats & Document;
@Schema({ timestamps: true })
export class MarketStats {
@Prop({ required: true })
date: string;
@Prop()
taiexPrice: number;
@Prop()
taiexChange: number;
@Prop()
taiexTradeValue: number;
@Prop()
finiNetBuySell: number;
@Prop()
sitcNetBuySell: number;
@Prop()
dealersNetBuySell: number;
@Prop()
marginBalance: number;
@Prop()
marginBalanceChange: number;
@Prop()
shortBalance: number;
@Prop()
shortBalanceChange: number;
@Prop()
finiTxfNetOi: number;
@Prop()
finiTxoCallsNetOiValue: number;
@Prop()
finiTxoPutsNetOiValue: number;
@Prop()
top10SpecificTxfFrontMonthNetOi: number;
@Prop()
top10SpecificTxfBackMonthsNetOi: number;
@Prop()
retailMxfNetOi: number;
@Prop()
retailMxfLongShortRatio: number;
@Prop()
txoPutCallRatio: number;
@Prop()
usdtwd: number;
}
export const MarketStatsSchema = SchemaFactory.createForClass(MarketStats)
.index({ date: -1 }, { unique: true });
在 MarketStatsSchema 中,各欄位的說明如下:
date:日期taiexPrice:加權指數taiexChange:加權指數漲跌taiexTradeValue:集中市場成交金額finiNetBuySell:集中市場外資買賣超sitcNetBuySell:集中市場投信買賣超dealersNetBuySell:集中市場自營商買賣超marginBalance:集中市場融資餘額marginBalanceChange:集中市場融資餘額增減shortBalance:集中市場融券餘額shortBalanceChange:集中市場融資餘額增減finiTxfNetOi:外資臺股期貨淨口數finiTxoCallsNetOiValue:外資臺指選擇權買權淨金額finiTxoPutsNetOiValue:外資臺指選擇權賣權淨金額top10SpecificTxfFrontMonthNetOi:十大特定法人近月臺股期貨淨部位top10SpecificTxfBackMonthsNetOi:十大特定法人遠月臺股期貨淨部位retailMxfNetOi:散戶小台淨部位retailMxfLongShortRatio:散戶小台多空比txoPutCallRatio:臺指選擇權 Put/Call Ratiousdtwd:美元兌新臺幣匯率我們在前面介紹的大盤相關數據不只於此,此處使用以上資料欄位做為範例說明,您可以根據自己的需要自行增減欄位,畢竟「多算勝,少算不勝」。
完成 MarketStatsSchema 後,我們繼續在 src/market-stats 目錄下建立 market-stats.repository.ts 檔案,實作 MarketStatsRepository 作為對資料庫存取的介面。我們在 MarketStatsRepository 實作 updateMarketStats() 方法,用來更新大盤籌碼數據:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { MarketStats, MarketStatsDocument } from './market-stats.schema';
@Injectable()
export class MarketStatsRepository {
constructor(
@InjectModel(MarketStats.name) private readonly model: Model<MarketStatsDocument>,
) {}
async updateMarketStats(marketStats: Partial<MarketStats>) {
const { date } = marketStats;
return this.model.updateOne({ date }, marketStats, { upsert: true });
}
}
在 updateMarketStats() 方法中,接收的 marketStats 參數物件必須指定 date 代表日期,表示要更新特定日期的大盤籌碼數據。
完成 MarketStatsRepository 後,打開終端機,使用 Nest CLI 建立 MarketStatsService:
$ nest g service market-stats --no-spec
執行命令後,Nest CLI 會在 src/market-stats 目錄下建立 market-stats.service.ts 檔案,並且將 MarketStatsService 加入至 MarketStatsModule 的 providers 設定。
在實作 MarketStatsService 之前,我們先開啟 src/market-stats/market-stats.module.ts 檔案,調整 MarketStatsModule 將完成的 MarketStatsSchema 與 MarketStatsRepository 加入至 MarketStatsModule 設定,並且匯入 ScraperModule:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { MarketStats, MarketStatsSchema } from './market-stats.schema';
import { MarketStatsRepository } from './market-stats.repository';
import { MarketStatsService } from './market-stats.service';
import { ScraperModule } from '../scraper/scraper.module';
@Module({
imports: [
MongooseModule.forFeature([
{ name: MarketStats.name, schema: MarketStatsSchema },
]),
ScraperModule,
],
providers: [MarketStatsRepository, MarketStatsService],
exports: [MarketStatsRepository, MarketStatsService],
})
export class MarketStatsModule {}
在 MarketStatsService 中,我們會運用到 ScraperModule 的 services 取得大盤資訊。為了在 MarketStatsModule 使用 ScraperModule 的 services,記得要在 ScraperModule 中將所有的 providers 透過 exports 匯出:
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TwseScraperService } from './twse-scraper.service';
import { TpexScraperService } from './tpex-scraper.service';
import { TaifexScraperService } from './taifex-scraper.service';
import { TdccScraperService } from './tdcc-scraper.service';
import { MopsScraperService } from './mops-scraper.service';
import { UsdtScraperService } from './usdt-scraper.service';
import { YahooFinanceService } from './yahoo-finance.service';
@Module({
imports: [HttpModule],
providers: [
TwseScraperService,
TpexScraperService,
TaifexScraperService,
TdccScraperService,
MopsScraperService,
UsdtScraperService,
YahooFinanceService,
],
exports: [
TwseScraperService,
TpexScraperService,
TaifexScraperService,
TdccScraperService,
MopsScraperService,
UsdtScraperService,
YahooFinanceService,
],
})
export class ScraperModule {}
在 Nest Framework 的設定中,需要將 provider 使用
exports匯出後,才可以被其他外部模組透過依賴注入的方式使用該 provider。
排程任務可以在特定的時間執行程式,透過設定排程任務,我們可以指定在特定的時間從交易所或相關網站取得資料。
Nest 官方提供了 @nestjs/schedule 套件,方便我們用裝飾器(decorator)聲明的方式實現排程任務。請開啟終端機安裝以下依賴套件:
$ npm install --save @nestjs/schedule
$ npm install --save-dev @types/cron
@nestjs/schedule使用類似 crontab 的描述,來設定重複性的排程任務。
為了啟用排程任務,在專案下開啟 src/app.module.ts 檔案,在根模組 AppModule 匯入 ScheduleModule:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { MongooseModule } from '@nestjs/mongoose';
import { ScraperModule } from './scraper/scraper.module';
import { MarketStatsModule } from './market-stats/market-stats.module';
@Module({
imports: [
ConfigModule.forRoot(),
ScheduleModule.forRoot(),
MongooseModule.forRoot(process.env.MONGODB_URI),
ScraperModule,
MarketStatsModule,
],
})
export class AppModule {}
然後開啟 src/market-stats/market-stats.service.ts 檔案,在 MarketStatsService 新增 updateMarketStats() 方法,更新大盤籌碼數據,並使用 @Cron() 裝飾器來聲明排程任務,分別執行取得大盤資訊的方法:
import { DateTime } from 'luxon';
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { MarketStatsRepository } from './market-stats.repository';
import { TwseScraperService } from '../scraper/twse-scraper.service';
import { TaifexScraperService } from '../scraper/taifex-scraper.service';
@Injectable()
export class MarketStatsService {
constructor(
private readonly marketStatsRepository: MarketStatsRepository,
private readonly twseScraperService: TwseScraperService,
private readonly taifexScraperService: TaifexScraperService,
) {}
async updateMarketStats(date: string = DateTime.local().toISODate()) {
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
await this.updateTaiex(date).then(() => delay(5000));
await this.updateInstInvestorsTrades(date).then(() => delay(5000));
await this.updateMarginTransactions(date).then(() => delay(5000));
await this.updateFiniTxfNetOi(date).then(() => delay(5000));
await this.updateFiniTxoNetOiValue(date).then(() => delay(5000));
await this.updateLargeTradersTxfNetOi(date).then(() => delay(5000));
await this.updateRetailMxfPosition(date).then(() => delay(5000));
await this.updateTxoPutCallRatio(date).then(() => delay(5000));
await this.updateUsdTwdRate(date).then(() => delay(5000));
Logger.log(`${date} 已完成`, MarketStatsService.name);
}
@Cron('0 0 15 * * *')
async updateTaiex(date: string) {
const updated = await this.twseScraperService.fetchMarketTrades(date)
.then(data => data && {
date,
taiexPrice: data.price,
taiexChange: data.change,
taiexTradeValue: data.tradeValue,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data))
if (updated) Logger.log(`${date} 集中市場加權指數: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 集中市場加權指數: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('0 30 15 * * *')
async updateInstInvestorsTrades(date: string) {
const updated = await this.twseScraperService.fetchInstInvestorsTrades(date)
.then(data => data && {
date,
finiNetBuySell: data.foreignInvestorsNetBuySell,
sitcNetBuySell: data.sitcNetBuySell,
dealersNetBuySell: data.dealersNetBuySell,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data))
if (updated) Logger.log(`${date} 集中市場三大法人買賣超: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 集中市場三大法人買賣超: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('0 30 21 * * *')
async updateMarginTransactions(date: string) {
const updated = await this.twseScraperService.fetchMarginTransactions(date)
.then(data => data && {
date,
marginBalance: data.marginBalance,
marginBalanceChange: data.marginBalanceChange,
shortBalance: data.shortBalance,
shortBalanceChange: data.shortBalanceChange,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data));
if (updated) Logger.log(`${date} 集中市場信用交易: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 集中市場信用交易: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('0 0 15 * * *')
async updateFiniTxfNetOi(date: string) {
const updated = await this.taifexScraperService.fetchInstInvestorsTxfTrades(date)
.then(data => data && {
date,
finiTxfNetOi: data.finiNetOiVolume,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data));
if (updated) Logger.log(`${date} 外資臺股期貨未平倉淨口數: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 外資臺股期貨未平倉淨口數: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('5 0 15 * * *')
async updateFiniTxoNetOiValue(date: string) {
const updated = await this.taifexScraperService.fetchInstInvestorsTxoTrades(date)
.then(data => data && {
date,
finiTxoCallsNetOiValue: data.finiCallsNetOiValue,
finiTxoPutsNetOiValue: data.finiPutsNetOiValue,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data));
if (updated) Logger.log(`${date} 外資臺指選擇權未平倉淨金額: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 外資臺指選擇權未平倉淨金額: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('10 0 15 * * *')
async updateLargeTradersTxfNetOi(date: string) {
const updated = await this.taifexScraperService.fetchLargeTradersTxfPosition(date)
.then(data => data && {
date,
top10SpecificTxfFrontMonthNetOi: data.top10SpecificFrontMonthNetOi,
top10SpecificTxfBackMonthsNetOi: data.top10SpecificBackMonthsNetOi,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data));
if (updated) Logger.log(`${date} 十大特法臺股期貨未平倉淨口數: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 十大特法臺股期貨未平倉淨口數: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('15 0 15 * * *')
async updateRetailMxfPosition(date: string) {
const updated = await this.taifexScraperService.fetchRetailMxfPosition(date)
.then(data => data && {
date,
retailMxfNetOi: data.retailMxfNetOi,
retailMxfLongShortRatio: data.retailMxfLongShortRatio,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data));
if (updated) Logger.log(`${date} 散戶小台淨部位: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 散戶小台淨部位: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('20 0 15 * * *')
async updateTxoPutCallRatio(date: string) {
const updated = await this.taifexScraperService.fetchTxoPutCallRatio(date)
.then(data => data && {
date,
txoPutCallRatio: data.txoPutCallRatio,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data));
if (updated) Logger.log(`${date} 臺指選擇權 Put/Call Ratio: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 臺指選擇權 Put/Call Ratio: 尚無資料或非交易日`, MarketStatsService.name);
}
@Cron('0 0 17 * * *')
async updateUsdTwdRate(date: string) {
const updated = await this.taifexScraperService.fetchExchangeRates(date)
.then(data => data && {
date,
usdtwd: data.usdtwd,
})
.then(data => data && this.marketStatsRepository.updateMarketStats(data));
if (updated) Logger.log(`${date} 美元兌新臺幣匯率: 已更新`, MarketStatsService.name);
else Logger.warn(`${date} 美元兌新臺幣匯率: 尚無資料或非交易日`, MarketStatsService.name);
}
}
在 MarketStatsService 中,我們實作 updateMarketStats() 方法更新大盤資訊,並且實作了以下方法及排程任務,定時取得各項大盤籌碼數據:
updateTaiex():更新集中市場加權指數updateInstInvestorsTrades():更新集中市場三大法人買賣超updateMarginTransactions():更新集中市場信用交易updateFiniTxfNetOi():更新外資臺股期貨未平倉淨口數updateFiniTxoNetOiValue():更新外資臺指選擇權未平倉淨金額updateLargeTradersTxfNetOi():更新十大特法臺股期貨未平倉淨口數updateRetailMxfPosition():更新散戶小台淨部位updateTxoPutCallRatio():更新臺指選擇權 Put/Call RatioupdateUsdTwdRate():更新美元兌新臺幣匯率以上方法皆需指定 date 日期參數,以更新特定日期的大盤資訊。呼叫完每個方法後,會延遲 5 秒再進行下一個更新任務,避免請求過於頻繁,被交易所或相關網站禁止存取。
完成 MarketStatsModule 處理大盤籌碼相關數據後,接下來我們要繼續完成 TickerModule 處理產業指數與個股行情資訊。
每一個產業分類股價指數和個股都有一組代號可以用來識別,我們稱作「Ticker」。為了整理產業指數及個股行情資訊,我們使用 Nest CLI 建立 TickerModule:
$ nest g module ticker
執行後,Nest CLI 會建立 src/ticker 目錄,並在該目錄下新增 ticker.module.ts 檔案,並且將 TickerModule 加入至 AppModule 的 imports 設定。
然後在 src/ticker 目錄下新增 ticker.schema.ts 檔案,我們要定義 TickerSchema,這代表的是一個 Mongoose Schema,定義產業指數或個股的行情資訊:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type TickerDocument = Ticker & Document;
@Schema({ timestamps: true })
export class Ticker {
@Prop({ required: true })
date: string;
@Prop()
type: string;
@Prop()
exchange: string;
@Prop()
market: string;
@Prop()
symbol: string;
@Prop()
name: string;
@Prop()
openPrice: number;
@Prop()
highPrice: number;
@Prop()
lowPrice: number;
@Prop()
closePrice: number;
@Prop()
change: number;
@Prop()
changePercent: number;
@Prop()
tradeVolume: number;
@Prop()
tradeValue: number;
@Prop()
transaction: number;
@Prop()
tradeWeight: number;
@Prop()
finiNetBuySell: number;
@Prop()
sitcNetBuySell: number;
@Prop()
dealersNetBuySell: number;
}
export const TickerSchema = SchemaFactory.createForClass(Ticker)
.index({ date: -1, symbol: 1 }, { unique: true });
在 TickerSchema 中,各欄位的說明如下:
date:日期type: 類型exchange:所屬交易所market:所屬市場別symbol:指數或股票代號name:指數或股票名稱openPrice:開盤價highPrice:最高價lowPrice:最低價closePrice:收盤價change:漲跌changePercent:漲跌幅tradeVolume:成交量tradeValue:成交金額transaction:成交筆數tradeWeight:成交比重finiNetBuySell:外資買賣超sitcNetBuySell:投信買賣超dealersNetBuySell:自營商買賣超完成 TickerSchema 後,我們繼續在 src/ticker 目錄下建立 ticker.repository.ts 檔案,實作 TickerRepository 作為對資料庫存取的介面。我們在 TickerRepository 實作 updateTicker() 方法,用來更新每日行情數據:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Ticker, TickerDocument } from './ticker.schema';
@Injectable()
export class TickerRepository {
constructor(
@InjectModel(Ticker.name) private readonly model: Model<TickerDocument>,
) {}
async updateTicker(ticker: Partial<Ticker>) {
const { date, symbol } = ticker;
return this.model.updateOne({ date, symbol }, ticker, { upsert: true });
}
}
在 updateTicker() 方法中,接收的 ticker 參數物件必須指定 date 代表日期,以及 symbol 代表指數或股票代號,表示要更新特定日期的指數或個股行情數據。
完成 TickerRepository 後,打開終端機,使用 Nest CLI 建立 TickerService:
$ nest g service ticker --no-spec
執行命令後,Nest CLI 會在 src/ticker 目錄下新增 ticker.service.ts 檔案,並且將 TickerService 加入至 TickerModule 的 providers 設定。
然後開啟 src/ticker/ticker.module.ts 檔案,調整 TickerModule 將完成的 TickerSchema 與 TickerRepository 加入至 TickerModule 設定,並且匯入 ScraperModule:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Ticker, TickerSchema } from './ticker.schema';
import { TickerRepository } from './ticker.repository';
import { TickerService } from './ticker.service';
import { ScraperModule } from '../scraper/scraper.module';
@Module({
imports: [
MongooseModule.forFeature([
{ name: Ticker.name, schema: TickerSchema },
]),
ScraperModule,
],
providers: [TickerRepository, TickerService],
exports: [TickerRepository, TickerService],
})
export class TickerModule {}
在實作 TickerService 之前,我們先在 libs/common 加入幾組列舉(Enum)型別,作為定義資料的常數。
在 libs/common/src/enums 目錄下新增 ticker-type.enum.ts 檔案,實作 TickerType 列舉型別,表示 Ticker 的類型:
export enum TickerType {
Equity = 'EQUITY',
Index = 'INDEX',
}
Equity 表示 證券 類型 Ticker;INDEX 表示 指數 類型 Ticker。
在 libs/common/src/enums 目錄下新增 exchange.enum.ts 檔案,實作 Exchange 列舉型別,表示 Ticker 所屬的交易所:
export enum Exchange {
TWSE = 'TWSE',
TPEx = 'TPEx',
}
TWSE 表示 臺灣證券交易所;TPEx 表示 證券櫃檯買賣中心。
在 libs/common/src/enums 目錄下新增 market.enum.ts 檔案,實作 Market 列舉型別,表示 Ticker 所屬的市場別:
export enum Market {
TSE = 'TSE',
OTC = 'OTC',
ESB = 'ESB',
TIB = 'TIB',
PSB = 'PSB',
}
TSE 表示 上市;OTC 表示 上櫃;ESB 表示 興櫃一般板;TIB 表示 臺灣創新板;PSB 表示 興櫃戰略新板。
下一步,在 libs/common/src/enums/index.ts 將這些列舉型別匯出:
export * from './ticker-type.enum';
export * from './exchange.enum';
export * from './market.enum';
然後我們就可以透過以下方式引用這些列舉型別資料:
import { TickerType, Exchange, Market } from '@speculator/common';
完成列舉的定義後,開啟 src/ticker/ticker.service 檔案,在 TickerService 新增 updateTickers() 方法更新產業指數及個股行情,並使用 @Cron() 裝飾器來聲明排程任務,分別執行取得指數及個股行情的方法:
import { DateTime } from 'luxon';
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { TickerType, Exchange, Market, Index } from '@speculator/common';
import { TickerRepository } from './ticker.repository';
import { TwseScraperService } from '../scraper/twse-scraper.service';
import { TpexScraperService } from '../scraper/tpex-scraper.service';
@Injectable()
export class TickerService {
constructor(
private readonly tickerRepository: TickerRepository,
private readonly twseScraperService: TwseScraperService,
private readonly tpexScraperService: TpexScraperService,
) {}
async updateTickers(date: string = DateTime.local().toISODate()) {
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
await Promise.all([
this.updateTwseIndicesQuotes(date),
this.updateTpexIndicesQuotes(date),
]).then(() => delay(5000));
await Promise.all([
this.updateTwseMarketTrades(date),
this.updateTpexMarketTrades(date),
]).then(() => delay(5000));
await Promise.all([
this.updateTwseIndicesTrades(date),
this.updateTpexIndicesTrades(date),
]).then(() => delay(5000));
await Promise.all([
this.updateTwseEquitiesQuotes(date),
this.updateTpexEquitiesQuotes(date),
]).then(() => delay(5000));
await Promise.all([
this.updateTwseEquitiesInstInvestorsTrades(date),
this.updateTpexEquitiesInstInvestorsTrades(date),
]).then(() => delay(5000));
Logger.log(`${date} 已完成`, TickerService.name);
}
@Cron('0 0 14 * * *')
async updateTwseIndicesQuotes(date: string = DateTime.local().toISODate()) {
const updated = await this.twseScraperService.fetchIndicesQuotes(date)
.then(data => data && data.map(ticker => ({
date: ticker.date,
type: TickerType.Index,
exchange: Exchange.TWSE,
market: Market.TSE,
symbol: ticker.symbol,
name: ticker.name,
openPrice: ticker.openPrice,
highPrice: ticker.highPrice,
lowPrice: ticker.lowPrice,
closePrice: ticker.closePrice,
change: ticker.change,
changePercent: ticker.changePercent,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上市指數收盤行情: 已更新`, TickerService.name);
else Logger.warn(`${date} 上市指數收盤行情: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 0 14 * * *')
async updateTpexIndicesQuotes(date: string = DateTime.local().toISODate()) {
const updated = await this.tpexScraperService.fetchIndicesQuotes(date)
.then(data => data && data.map(ticker => ({
date: ticker.date,
type: TickerType.Index,
exchange: Exchange.TPEx,
market: Market.OTC,
symbol: ticker.symbol,
name: ticker.name,
openPrice: ticker.openPrice,
highPrice: ticker.highPrice,
lowPrice: ticker.lowPrice,
closePrice: ticker.closePrice,
change: ticker.change,
changePercent: ticker.changePercent,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上櫃指數收盤行情: 已更新`, TickerService.name);
else Logger.warn(`${date} 上櫃指數收盤行情: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 30 14 * * *')
async updateTwseMarketTrades(date: string = DateTime.local().toISODate()) {
const updated = await this.twseScraperService.fetchMarketTrades(date)
.then(data => data && {
date,
type: TickerType.Index,
exchange: Exchange.TWSE,
market: Market.TSE,
symbol: Index.TAIEX,
tradeVolume: data.tradeVolume,
tradeValue: data.tradeValue,
transaction: data.transaction,
})
.then(ticker => ticker && this.tickerRepository.updateTicker(ticker));
if (updated) Logger.log(`${date} 上市大盤成交量值: 已更新`, TickerService.name);
else Logger.warn(`${date} 上市大盤成交量值: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 30 14 * * *')
async updateTpexMarketTrades(date: string = DateTime.local().toISODate()) {
const updated = await this.tpexScraperService.fetchMarketTrades(date)
.then(data => data && {
date,
type: TickerType.Index,
exchange: Exchange.TPEx,
market: Market.OTC,
symbol: Index.TPEX,
tradeVolume: data.tradeVolume,
tradeValue: data.tradeValue,
transaction: data.transaction,
})
.then(ticker => ticker && this.tickerRepository.updateTicker(ticker));
if (updated) Logger.log(`${date} 上櫃大盤成交量值: 已更新`, TickerService.name);
else Logger.warn(`${date} 上櫃大盤成交量值: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 0 15 * * *')
async updateTwseIndicesTrades(date: string = DateTime.local().toISODate()) {
const updated = await this.twseScraperService.fetchIndicesTrades(date)
.then(data => data && data.map(ticker => ({
date: ticker.date,
type: TickerType.Index,
exchange: Exchange.TWSE,
market: Market.TSE,
symbol: ticker.symbol,
tradeVolume: ticker.tradeVolume,
tradeValue: ticker.tradeValue,
tradeWeight: ticker.tradeWeight,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上市類股成交量值: 已更新`, TickerService.name);
else Logger.warn(`${date} 上市類股成交量值: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 0 15 * * *')
async updateTpexIndicesTrades(date: string = DateTime.local().toISODate()) {
const updated = await this.tpexScraperService.fetchIndicesTrades(date)
.then(data => data && data.map(ticker => ({
date: ticker.date,
type: TickerType.Index,
exchange: Exchange.TPEx,
market: Market.OTC,
symbol: ticker.symbol,
tradeVolume: ticker.tradeVolume,
tradeValue: ticker.tradeValue,
tradeWeight: ticker.tradeWeight,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上櫃類股成交量值: 已更新`, TickerService.name);
else Logger.warn(`${date} 上櫃類股成交量值: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 0 15-21/2 * * *')
async updateTwseEquitiesQuotes(date: string = DateTime.local().toISODate()) {
const updated = await this.twseScraperService.fetchEquitiesQuotes(date)
.then(data => data && data.map(ticker => ({
date: ticker.date,
type: TickerType.Equity,
exchange: Exchange.TWSE,
market: Market.TSE,
symbol: ticker.symbol,
name: ticker.name,
openPrice: ticker.openPrice,
highPrice: ticker.highPrice,
lowPrice: ticker.lowPrice,
closePrice: ticker.closePrice,
change: ticker.change,
changePercent: ticker.changePercent,
tradeVolume: ticker.tradeVolume,
tradeValue: ticker.tradeValue,
transaction: ticker.transaction,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上市個股收盤行情: 已更新`, TickerService.name);
else Logger.warn(`${date} 上市個股收盤行情: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 0 15-21/2 * * *')
async updateTpexEquitiesQuotes(date: string = DateTime.local().toISODate()) {
const updated = await this.tpexScraperService.fetchEquitiesQuotes(date)
.then(data => data && data.map(ticker => ({ ...ticker,
date: ticker.date,
type: TickerType.Equity,
exchange: Exchange.TPEx,
market: Market.OTC,
symbol: ticker.symbol,
name: ticker.name,
openPrice: ticker.openPrice,
highPrice: ticker.highPrice,
lowPrice: ticker.lowPrice,
closePrice: ticker.closePrice,
change: ticker.change,
changePercent: ticker.changePercent,
tradeVolume: ticker.tradeVolume,
tradeValue: ticker.tradeValue,
transaction: ticker.transaction,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上櫃個股收盤行情: 已更新`, TickerService.name);
else Logger.warn(`${date} 上櫃個股收盤行情: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 30 16 * * *')
async updateTwseEquitiesInstInvestorsTrades(date: string = DateTime.local().toISODate()) {
const updated = await this.twseScraperService.fetchEquitiesInstInvestorsTrades(date)
.then(data => data && data.map(ticker => ({
date: ticker.date,
type: TickerType.Equity,
exchange: Exchange.TWSE,
market: Market.TSE,
symbol: ticker.symbol,
finiNetBuySell: ticker.foreignInvestorsNetBuySell,
sitcNetBuySell: ticker.sitcNetBuySell,
dealersNetBuySell: ticker.dealersNetBuySell,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上市個股法人進出: 已更新`, TickerService.name);
else Logger.warn(`${date} 上市個股法人進出: 尚無資料或非交易日`, TickerService.name);
}
@Cron('0 30 16 * * *')
async updateTpexEquitiesInstInvestorsTrades(date: string = DateTime.local().toISODate()) {
const updated = await this.tpexScraperService.fetchEquitiesInstInvestorsTrades(date)
.then(data => data && data.map(ticker => ({
date: ticker.date,
type: TickerType.Equity,
exchange: Exchange.TPEx,
market: Market.OTC,
symbol: ticker.symbol,
finiNetBuySell: ticker.foreignInvestorsNetBuySell,
sitcNetBuySell: ticker.sitcNetBuySell,
dealersNetBuySell: ticker.dealersNetBuySell,
})))
.then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));
if (updated) Logger.log(`${date} 上櫃個股法人進出: 已更新`, TickerService.name);
else Logger.warn(`${date} 上櫃個股法人進出: 尚無資料或非交易日`, TickerService.name);
}
}
在 TickerService 中,我們實作 updateTickers() 方法,更新指數與個股行情資訊,並且實作了以下方法及排程任務,定時取得各項行情數據:
updateTwseIndicesQuotes():更新集中市場指數行情updateTpexIndicesQuotes():更新櫃買市場指數行情updateTwseMarketTrades():更新集中市場成交量值updateTpexMarketTrades():更新櫃買市場成交量值updateTwseIndicesTrades():更新集中市場產業指數成交量值updateTpexIndicesTrades():更新櫃買市場產業指數成交量值updateTwseEquitiesQuotes():更新上市個股行情updateTpexEquitiesQuotes():更新上櫃個股行情updateTwseEquitiesInstInvestorsTrades():更新上市個股法人進出updateTpexEquitiesInstInvestorsTrades():更新上櫃個股法人進出以上方法皆需指定 date 日期參數,以更新特定日期的行情資訊。呼叫完每個方法後,會延遲 5 秒再進行下一個更新任務,避免請求過於頻繁,被交易所或相關網站禁止存取。
完成 MarketStatsModule 與 TickerModule 後,幾乎已經完成我們的股市資料庫了。不過在第一次執行應用程式時,我們需要進行初始化工作。
開啟 src/app.module 檔案,調整 AppModule,並加入 onApplicationBootstrap() 方法:
import { DateTime } from 'luxon';
import { Module, OnApplicationBootstrap, Logger } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ScraperModule } from './scraper/scraper.module';
import { MarketStatsModule } from './market-stats/market-stats.module';
import { TickerModule } from './ticker/ticker.module';
import { MarketStatsService } from './market-stats/market-stats.service';
import { TickerService } from './ticker/ticker.service';
@Module({
imports: [
ConfigModule.forRoot(),
ScheduleModule.forRoot(),
MongooseModule.forRoot(process.env.MONGODB_URI),
ScraperModule,
MarketStatsModule,
TickerModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly marketStatsService: MarketStatsService,
private readonly tickerService: TickerService
) {}
async onApplicationBootstrap() {
if (process.env.SCRAPER_INIT === 'true') {
Logger.log('正在初始化應用程式...', AppModule.name);
for (let dt = DateTime.local(), days = 0; days < 31; dt = dt.minus({ day: 1 }), days++) {
await this.marketStatsService.updateMarketStats(dt.toISODate());
await this.tickerService.updateTickers(dt.toISODate());
}
Logger.log('應用程式初始化完成', AppModule.name);
}
}
}
透過讀取環境變數 process.env.SCRAPER_INIT 決定是否要進行初始化。當要進行初始化時,我們只要在專案根目錄下的 .env 檔案加入:
SCRAPER_INIT=true
當應用程式啟動時,就會進行 Scraper 應用程式的初始化工作。透過 MarketStatsService 的 updateMarketStats() 方法,取得近一個月的大盤籌碼;透過 TickerService 的 updateTickers() 方法,取得近一個月的指數與個股行情資訊。
至此,我們已經完成了「Market Stats Module」與「Ticker Module」,明天我們將繼續完成「Report Module」以產出我們的市場觀察報告。
本系列文已正式出版為《Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰》。本書新增了全新內容和實用範例,為你提供更深入的學習體驗!歡迎參考選購,開始你的量化投資之旅!
天瓏網路書店連結:https://www.tenlong.com.tw/products/9786263336070